# Copyright 2022 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""WORK IN PROGRESS!

# Overview of implementation

(If you just want to use the macros, see their docstrings; this section is
intended to orient future maintainers.)

Proto code generation is carried out by the _pwpb_proto_library,
_nanopb_proto_library, _pw_raw_rpc_proto_library and
_pw_nanopb_rpc_proto_library rules using aspects
(https://docs.bazel.build/versions/main/skylark/aspects.html).

As an example, _pwpb_proto_library has a single proto_library as a dependency,
but that proto_library may depend on other proto_library targets; as a result,
the generated .pwpb.h file #include's .pwpb.h files generated from the
dependency proto_libraries. The aspect propagates along the proto_library
dependency graph, running the proto compiler on each proto_library in the
original target's transitive dependencies, ensuring that we're not missing any
.pwpb.h files at C++ compile time.

Although we have a separate rule for each protocol compiler plugin
(_pwpb_proto_library, _nanopb_proto_library, _pw_raw_rpc_proto_library,
_pw_nanopb_rpc_proto_library), they actually share an implementation
(_impl_pw_proto_library) and use similar aspects, all generated by
_proto_compiler_aspect.
"""

load("@bazel_skylib//lib:paths.bzl", "paths")
load("@bazel_tools//tools/cpp:toolchain_utils.bzl", "use_cpp_toolchain")
load(
    "@pigweed//pw_build/bazel_internal:pigweed_internal.bzl",
    _compile_cc = "compile_cc",
)
load("@rules_proto//proto:defs.bzl", "ProtoInfo")

# For Copybara use only
ADDITIONAL_PWPB_DEPS = []

def pwpb_proto_library(name, deps, tags = None, visibility = None):
    """A C++ proto library generated using pw_protobuf.

    Attributes:
      deps: proto_library targets for which to generate this library.
    """
    _pwpb_proto_library(
        name = name,
        protos = deps,
        deps = [
            Label("//pw_assert"),
            Label("//pw_containers:vector"),
            Label("//pw_preprocessor"),
            Label("//pw_protobuf"),
            Label("//pw_result"),
            Label("//pw_span"),
            Label("//pw_status"),
            Label("//pw_string:string"),
        ] + ADDITIONAL_PWPB_DEPS,
        tags = tags,
        visibility = visibility,
    )

def pwpb_rpc_proto_library(name, deps, pwpb_proto_library_deps, tags = None, visibility = None):
    """A pwpb_rpc proto library target.

    Attributes:
      deps: proto_library targets for which to generate this library.
      pwpb_proto_library_deps: A pwpb_proto_library generated
        from the same proto_library. Required.
    """
    _pw_pwpb_rpc_proto_library(
        name = name,
        protos = deps,
        deps = [
            Label("//pw_protobuf"),
            Label("//pw_rpc"),
            Label("//pw_rpc/pwpb:client_api"),
            Label("//pw_rpc/pwpb:server_api"),
        ] + pwpb_proto_library_deps,
        tags = tags,
        visibility = visibility,
    )

def raw_rpc_proto_library(name, deps, tags = None, visibility = None):
    """A raw C++ RPC proto library."""
    _pw_raw_rpc_proto_library(
        name = name,
        protos = deps,
        deps = [
            Label("//pw_rpc"),
            Label("//pw_rpc/raw:client_api"),
            Label("//pw_rpc/raw:server_api"),
        ],
        tags = tags,
        visibility = visibility,
    )

# TODO: b/234873954 - Enable unused variable check.
# buildifier: disable=unused-variable
def nanopb_proto_library(name, deps, tags = [], visibility = None, options = None):
    """A C++ proto library generated using pw_protobuf.

    Attributes:
      deps: proto_library targets for which to generate this library.
    """

    # TODO(tpudlik): Find a way to get Nanopb to generate nested structs.
    # Otherwise add the manual tag to the resulting library, preventing it
    # from being built unless directly depended on.  e.g. The 'Pigweed'
    # message in
    # pw_protobuf/pw_protobuf_test_protos/full_test.proto will fail to
    # compile as it has a self referring nested message. According to
    # the docs
    # https://jpa.kapsi.fi/nanopb/docs/reference.html#proto-file-options
    # and https://github.com/nanopb/nanopb/issues/433 it seems like it
    # should be possible to configure nanopb to generate nested structs via
    # flags in .options files.
    #
    # One issue is nanopb doesn't silently ignore unknown options in .options
    # files so we can't share .options files with pwpb.
    extra_tags = ["manual"]
    _nanopb_proto_library(
        name = name,
        protos = deps,
        deps = [
            "@com_github_nanopb_nanopb//:nanopb",
            Label("//pw_assert"),
            Label("//pw_containers:vector"),
            Label("//pw_preprocessor"),
            Label("//pw_result"),
            Label("//pw_span"),
            Label("//pw_status"),
            Label("//pw_string:string"),
        ],
        tags = tags + extra_tags,
        visibility = visibility,
    )

def nanopb_rpc_proto_library(name, deps, nanopb_proto_library_deps, tags = [], visibility = None):
    """A C++ RPC proto library using nanopb.

    Attributes:
      deps: proto_library targets for which to generate this library.
      nanopb_proto_library_deps: A pw_nanopb_cc_library generated
        from the same proto_library. Required.
    """

    # See comment in nanopb_proto_library.
    extra_tags = ["manual"]
    _pw_nanopb_rpc_proto_library(
        name = name,
        protos = deps,
        # TODO: b/339280821 - This is required to avoid breaking internal
        # Google builds but shouldn't matter for any external user. Remove this
        # when possible.
        features = ["-layering_check"],
        deps = [
            Label("//pw_rpc"),
            Label("//pw_rpc/nanopb:client_api"),
            Label("//pw_rpc/nanopb:server_api"),
        ] + nanopb_proto_library_deps,
        tags = tags + extra_tags,
        visibility = visibility,
    )

def pw_proto_library(
        name,
        deps,
        visibility = None,
        tags = [],
        nanopb_options = None,
        enabled_targets = None):
    """Generate Pigweed proto C++ code.

    DEPRECATED. This macro is deprecated and will be removed in a future
    Pigweed version. Please use the single-target macros above.

    Args:
      name: The name of the target.
      deps: proto_library targets from which to generate Pigweed C++.
      visibility: The visibility of the target. See
         https://bazel.build/concepts/visibility.
      tags: Tags for the target. See
         https://bazel.build/reference/be/common-definitions#common-attributes.
      nanopb_options: path to file containing nanopb options, if any
        (https://jpa.kapsi.fi/nanopb/docs/reference.html#proto-file-options).
      enabled_targets: Specifies which libraries should be generated. Libraries
        will only be generated as needed, but unnecessary outputs may conflict
        with other build rules and thus cause build failures. This filter allows
        manual selection of which libraries should be supported by this build
        target in order to prevent such conflicts. The argument, if provided,
        should be a subset of ["pwpb", "nanopb", "raw_rpc", "nanopb_rpc"]. All
        are enabled by default. Note that "nanopb_rpc" relies on "nanopb".

    Example usage:

      proto_library(
        name = "benchmark_proto",
        srcs = [
          "benchmark.proto",
        ],
      )

      pw_proto_library(
        name = "benchmark_pw_proto",
        deps = [":benchmark_proto"],
      )

      pw_cc_binary(
        name = "proto_user",
        srcs = ["proto_user.cc"],
        deps = [":benchmark_pw_proto.pwpb"],
      )

    The pw_proto_library generates the following targets in this example:

    "benchmark_pw_proto.pwpb": C++ library exposing the "benchmark.pwpb.h" header.
    "benchmark_pw_proto.pwpb_rpc": C++ library exposing the
        "benchmark.rpc.pwpb.h" header.
    "benchmark_pw_proto.raw_rpc": C++ library exposing the "benchmark.raw_rpc.h"
        header.
    "benchmark_pw_proto.nanopb": C++ library exposing the "benchmark.pb.h"
        header.
    "benchmark_pw_proto.nanopb_rpc": C++ library exposing the
        "benchmark.rpc.pb.h" header.
    """

    def is_plugin_enabled(plugin):
        return (enabled_targets == None or plugin in enabled_targets)

    if is_plugin_enabled("nanopb"):
        # Use nanopb to generate the pb.h and pb.c files, and the target
        # exposing them.
        nanopb_proto_library(
            name = name + ".nanopb",
            deps = deps,
            tags = tags,
            visibility = visibility,
            options = nanopb_options,
        )

    if is_plugin_enabled("pwpb"):
        pwpb_proto_library(
            name = name + ".pwpb",
            deps = deps,
            tags = tags,
            visibility = visibility,
        )

    if is_plugin_enabled("pwpb_rpc"):
        pwpb_rpc_proto_library(
            name = name + ".pwpb_rpc",
            deps = deps,
            pwpb_proto_library_deps = [":" + name + ".pwpb"],
            tags = tags,
            visibility = visibility,
        )

    if is_plugin_enabled("raw_rpc"):
        raw_rpc_proto_library(
            name = name + ".raw_rpc",
            deps = deps,
            tags = tags,
            visibility = visibility,
        )

    if is_plugin_enabled("nanopb_rpc"):
        nanopb_rpc_proto_library(
            name = name + ".nanopb_rpc",
            deps = deps,
            nanopb_proto_library_deps = [":" + name + ".nanopb"],
            tags = tags,
            visibility = visibility,
        )

PwProtoInfo = provider(
    "Returned by PW proto compilation aspect",
    fields = {
        "hdrs": "generated C++ header files",
        "includes": "include paths for generated C++ header files",
        "srcs": "generated C++ src files",
    },
)

PwProtoOptionsInfo = provider(
    "Allows `pw_proto_filegroup` targets to pass along `.options` files " +
    "without polluting the `DefaultInfo` provider, which means they can " +
    "still be used in the `srcs` of `proto_library` targets.",
    fields = {
        "options_files": (".options file(s) associated with a proto_library " +
                          "for Pigweed codegen."),
    },
)

def _proto_compiler_aspect_impl(target, ctx):
    # List the files we will generate for this proto_library target.
    proto_info = target[ProtoInfo]

    srcs = []
    hdrs = []

    # Setup the output root for the plugin to point to targets output
    # directory. This allows us to declare the location of the files that protoc
    # will output in a way that `ctx.actions.declare_file` will understand,
    # since it works relative to the target.
    out_path = ctx.bin_dir.path
    if target.label.workspace_root:
        out_path += "/" + target.label.workspace_root
    if target.label.package:
        out_path += "/" + target.label.package

    # Add location of headers to cc include path.
    # Depending on prefix rules, the include path can be directly from the
    # output path, or underneath the package.
    includes = [out_path]

    for src in proto_info.direct_sources:
        # Get the relative import path for this .proto file.
        src_rel = paths.relativize(src.path, proto_info.proto_source_root)
        proto_dir = paths.dirname(src_rel)

        # Add location of headers to cc include path.
        includes.append("{}/{}".format(out_path, src.owner.package))

        for ext in ctx.attr._extensions:
            # Declare all output files, in target package dir.
            generated_filename = src.basename[:-len("proto")] + ext
            if proto_dir:
                out_file_name = "{}/{}".format(
                    proto_dir,
                    generated_filename,
                )
            else:
                out_file_name = generated_filename

            out_file = ctx.actions.declare_file(out_file_name)

            if ext.endswith(".h"):
                hdrs.append(out_file)
            else:
                srcs.append(out_file)

    # List the `.options` files from any `pw_proto_filegroup` targets listed
    # under this target's `srcs`.
    options_files = [
        options_file
        for src in ctx.rule.attr.srcs
        if PwProtoOptionsInfo in src
        for options_file in src[PwProtoOptionsInfo].options_files.to_list()
    ]

    # Local repository options files.
    options_file_include_paths = [paths.join(".", ctx.rule.attr.strip_import_prefix.lstrip("/"))]
    for options_file in options_files:
        # Handle .options files residing in external repositories.
        if options_file.owner.workspace_root:
            options_file_include_paths.append(
                paths.join(
                    options_file.owner.workspace_root,
                    ctx.rule.attr.strip_import_prefix.lstrip("/"),
                ),
            )

        # Handle generated .options files.
        if options_file.root.path:
            options_file_include_paths.append(
                paths.join(
                    options_file.root.path,
                    ctx.rule.attr.strip_import_prefix.lstrip("/"),
                ),
            )

    args = ctx.actions.args()
    for path in proto_info.transitive_proto_path.to_list():
        args.add("-I{}".format(path))

    args.add("--plugin=protoc-gen-custom={}".format(ctx.executable._protoc_plugin.path))

    # Convert include paths to a depset and back to deduplicate entries.
    for options_file_include_path in depset(options_file_include_paths).to_list():
        args.add("--custom_opt=-I{}".format(options_file_include_path))

    for plugin_option in ctx.attr._plugin_options:
        # if import_prefix is set, the .proto is placed under a virtual include path
        # prefixed by `import_prefix`. That path is what is given to the proto
        # plugin via plugin_pb2.CodeGeneratorRequest.proto_file.name, so the include
        # paths we give to the plugin need to be able find the .options files based
        # on the following logic in pw_protobuf/options.py:
        #
        #   options_file_name = include_path / proto_file_name.with_suffix(".options")
        #
        # This means that in order for the plugin to find the .options file, we need
        # to let the plugin know the import prefix so it can modify the `proto_file_name`
        # back to the original to be able to find the .options file.
        if plugin_option == "--import-prefix={}":
            if ctx.rule.attr.import_prefix:
                plugin_option = plugin_option.format(ctx.rule.attr.import_prefix)
            else:
                continue
        args.add("--custom_opt={}".format(plugin_option))

    args.add("--custom_out={}".format(out_path))
    args.add_all(proto_info.direct_sources)

    all_tools = [
        ctx.executable._protoc,
        ctx.executable._python_runtime,
        ctx.executable._protoc_plugin,
    ]
    run_path = [tool.dirname for tool in all_tools]

    ctx.actions.run(
        inputs = depset(
            direct = proto_info.direct_sources +
                     proto_info.transitive_sources.to_list() +
                     options_files,
            transitive = [proto_info.transitive_descriptor_sets],
        ),
        progress_message = "Generating %s C++ files for %s" % (ctx.attr._extensions, ctx.label.name),
        tools = all_tools,
        outputs = srcs + hdrs,
        executable = ctx.executable._protoc,
        arguments = [args],
        env = {

            # The nanopb protobuf plugin likes to compile some temporary protos
            # next to source files. This forces them to be written to Bazel's
            # genfiles directory.
            "NANOPB_PB2_TEMP_DIR": str(ctx.genfiles_dir),
            "PATH": ":".join(run_path),
        },
    )

    transitive_srcs = srcs
    transitive_hdrs = hdrs
    transitive_includes = includes
    for dep in ctx.rule.attr.deps:
        transitive_srcs += dep[PwProtoInfo].srcs
        transitive_hdrs += dep[PwProtoInfo].hdrs
        transitive_includes += dep[PwProtoInfo].includes
    return [PwProtoInfo(
        srcs = transitive_srcs,
        hdrs = transitive_hdrs,
        includes = transitive_includes,
    )]

def _proto_compiler_aspect(extensions, protoc_plugin, plugin_options = []):
    """Returns an aspect that runs the proto compiler.

    The aspect propagates through the deps of proto_library targets, running
    the proto compiler with the specified plugin for each of their source
    files. The proto compiler is assumed to produce one output file per input
    .proto file. That file is placed under bazel-bin at the same path as the
    input file, but with the specified extension (i.e., with _extensions = [
    .pwpb.h], the aspect converts pw_log/log.proto into
    bazel-bin/pw_log/log.pwpb.h).

    The aspect returns a provider exposing all the File objects generated from
    the dependency graph.
    """
    return aspect(
        attr_aspects = ["deps"],
        attrs = {
            "_extensions": attr.string_list(default = extensions),
            "_plugin_options": attr.string_list(
                default = plugin_options,
            ),
            "_protoc": attr.label(
                default = Label("@com_google_protobuf//:protoc"),
                executable = True,
                cfg = "exec",
            ),
            "_protoc_plugin": attr.label(
                default = Label(protoc_plugin),
                executable = True,
                cfg = "exec",
            ),
            "_python_runtime": attr.label(
                default = Label("//:python3_interpreter"),
                allow_single_file = True,
                executable = True,
                cfg = "exec",
            ),
        },
        implementation = _proto_compiler_aspect_impl,
        provides = [PwProtoInfo],
    )

def _impl_pw_proto_library(ctx):
    """Implementation of the proto codegen rule.

    The work of actually generating the code is done by the aspect, so here we
    compile and return a CcInfo to link against.
    """

    # Note that we don't distinguish between the files generated from the
    # target, and the files generated from its dependencies. We return all of
    # them together, and in pw_proto_library expose all of them as hdrs.
    # Pigweed's plugins happen to only generate .h files, so this works, but
    # strictly speaking we should expose only the files generated from the
    # target itself in hdrs, and place the headers generated from dependencies
    # in srcs. We don't perform layering_check in Pigweed, so this is not a big
    # deal.
    #
    # TODO: b/234873954 - Tidy this up.
    all_srcs = []
    all_hdrs = []
    all_includes = []
    for dep in ctx.attr.protos:
        for f in dep[PwProtoInfo].hdrs:
            all_hdrs.append(f)
        for f in dep[PwProtoInfo].srcs:
            all_srcs.append(f)
        for i in dep[PwProtoInfo].includes:
            all_includes.append(i)

    return _compile_cc(
        ctx,
        all_srcs,
        all_hdrs,
        ctx.attr.deps,
        all_includes,
        defines = [],
    )

# Instantiate the aspects and rules for generating code using specific plugins.
_pwpb_proto_compiler_aspect = _proto_compiler_aspect(
    ["pwpb.h"],
    "//pw_protobuf/py:plugin",
    ["--no-legacy-namespace", "--import-prefix={}"],
)

_pwpb_proto_library = rule(
    implementation = _impl_pw_proto_library,
    attrs = {
        "deps": attr.label_list(
            providers = [CcInfo],
        ),
        "protos": attr.label_list(
            providers = [ProtoInfo],
            aspects = [_pwpb_proto_compiler_aspect],
        ),
    },
    fragments = ["cpp"],
    toolchains = use_cpp_toolchain(),
)

_nanopb_proto_compiler_aspect = _proto_compiler_aspect(
    ["pb.h", "pb.c"],
    "@com_github_nanopb_nanopb//:protoc-gen-nanopb",
    ["--library-include-format=quote"],
)

_nanopb_proto_library = rule(
    implementation = _impl_pw_proto_library,
    attrs = {
        "deps": attr.label_list(
            providers = [CcInfo],
        ),
        "protos": attr.label_list(
            providers = [ProtoInfo],
            aspects = [_nanopb_proto_compiler_aspect],
        ),
    },
    fragments = ["cpp"],
    toolchains = use_cpp_toolchain(),
)

_pw_pwpb_rpc_proto_compiler_aspect = _proto_compiler_aspect(
    ["rpc.pwpb.h"],
    "//pw_rpc/py:plugin_pwpb",
    ["--no-legacy-namespace"],
)

_pw_pwpb_rpc_proto_library = rule(
    implementation = _impl_pw_proto_library,
    attrs = {
        "deps": attr.label_list(
            providers = [CcInfo],
        ),
        "protos": attr.label_list(
            providers = [ProtoInfo],
            aspects = [_pw_pwpb_rpc_proto_compiler_aspect],
        ),
    },
    fragments = ["cpp"],
    toolchains = use_cpp_toolchain(),
)

_pw_raw_rpc_proto_compiler_aspect = _proto_compiler_aspect(
    ["raw_rpc.pb.h"],
    "//pw_rpc/py:plugin_raw",
    ["--no-legacy-namespace"],
)

_pw_raw_rpc_proto_library = rule(
    implementation = _impl_pw_proto_library,
    attrs = {
        "deps": attr.label_list(
            providers = [CcInfo],
        ),
        "protos": attr.label_list(
            providers = [ProtoInfo],
            aspects = [_pw_raw_rpc_proto_compiler_aspect],
        ),
    },
    fragments = ["cpp"],
    toolchains = use_cpp_toolchain(),
)

_pw_nanopb_rpc_proto_compiler_aspect = _proto_compiler_aspect(
    ["rpc.pb.h"],
    "//pw_rpc/py:plugin_nanopb",
    ["--no-legacy-namespace"],
)

_pw_nanopb_rpc_proto_library = rule(
    implementation = _impl_pw_proto_library,
    attrs = {
        "deps": attr.label_list(
            providers = [CcInfo],
        ),
        "protos": attr.label_list(
            providers = [ProtoInfo],
            aspects = [_pw_nanopb_rpc_proto_compiler_aspect],
        ),
    },
    fragments = ["cpp"],
    toolchains = use_cpp_toolchain(),
)

def _pw_proto_filegroup_impl(ctx):
    source_files = list()
    options_files = list()

    for src in ctx.attr.srcs:
        source_files += src.files.to_list()

    for options_src in ctx.attr.options_files:
        for file in options_src.files.to_list():
            if file.extension == "options":
                options_files.append(file)
            else:
                fail((
                    "Files provided as `options_files` to a " +
                    "`pw_proto_filegroup` must have the `.options` " +
                    "extension; the file `{}` was provided."
                ).format(file.basename))

    return [
        DefaultInfo(files = depset(source_files)),
        PwProtoOptionsInfo(options_files = depset(options_files)),
    ]

pw_proto_filegroup = rule(
    doc = (
        "Acts like a `filegroup`, but with an additional `options_files` " +
        "attribute that accepts a list of `.options` files. These `.options` " +
        "files should typically correspond to `.proto` files provided under " +
        "the `srcs` attribute." +
        "\n\n" +
        "A `pw_proto_filegroup` is intended to be passed into the `srcs` of " +
        "a `proto_library` target as if it were a normal `filegroup` " +
        "containing only `.proto` files. For the purposes of the " +
        "`proto_library` itself, the `pw_proto_filegroup` does indeed act " +
        "just like a normal `filegroup`; the `options_files` attribute is " +
        "ignored. However, if that `proto_library` target is then passed " +
        "(directly or transitively) into the `deps` of a `pw_proto_library` " +
        "for code generation, the `pw_proto_library` target will have access " +
        "to the provided `.options` files and will pass them to the code " +
        "generator." +
        "\n\n" +
        "Note that, in order for a `pw_proto_filegroup` to be a valid `srcs` " +
        "entry for a `proto_library`, it must meet the same conditions " +
        "required of a standard `filegroup` in that context. Namely, its " +
        "`srcs` must provide at least one `.proto` (or `.protodevel`) file. " +
        "Put simply, a `pw_proto_filegroup` cannot be used as a vector for " +
        "injecting solely `.options` files; it must contain at least one " +
        "proto as well (generally one associated with an included `.options` " +
        "file in the interest of clarity)." +
        "\n\n" +
        "Regarding the somewhat unusual usage, this feature's design was " +
        "mostly preordained by the combination of Bazel's strict access " +
        "controls, the restrictions imposed on inputs to the `proto_library` " +
        "rule, and the need to support `.options` files from transitive " +
        "dependencies."
    ),
    implementation = _pw_proto_filegroup_impl,
    attrs = {
        "options_files": attr.label_list(
            allow_files = True,
        ),
        "srcs": attr.label_list(
            allow_files = True,
        ),
    },
    provides = [PwProtoOptionsInfo],
)
